Chapter Appendix B: Common Pitfalls and How to Avoid Them
Introduction: The Catalog of Failure
This appendix is your field guide to the most common ways React applications fail. Unlike the chapter-by-chapter progression where we built understanding through controlled failures, this is a reference catalog—organized by symptom, not by concept.
When your application breaks, you don't think "I need to review Chapter 4 on useEffect." You think "Why is my component rendering infinitely?" or "Why isn't my state updating?" This appendix meets you at that moment of confusion.
How to Use This Catalog
Each pitfall follows this structure:
- Symptom: What you observe in the browser
- Browser Evidence: Console output, DevTools observations, Network tab patterns
- Root Cause: The underlying mechanism causing the failure
- The Trap: Why this mistake is so common
- The Fix: Concrete solution with before/after code
- Prevention: How to avoid this in the future
Reference Implementation: Throughout this appendix, we'll use a TaskManager component as our recurring example—a realistic application that manages tasks with filtering, sorting, and real-time updates. This gives us a consistent context for demonstrating each pitfall.
// TaskManager.tsx - Our reference implementation
// We'll show how various pitfalls manifest in this component
interface Task {
id: string;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: Date;
}
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const [sortBy, setSortBy] = useState<'date' | 'priority'>('date');
// We'll see this component fail in many ways...
return (
<div>
<TaskList tasks={tasks} />
<TaskFilters filter={filter} onFilterChange={setFilter} />
</div>
);
}
State Management Pitfalls
Pitfall 1: The Stale Closure Trap
Symptom
Event handlers or callbacks reference old state values even after state updates. Clicking a button multiple times shows the same old value in the console, or operations use outdated data.
Browser Evidence
Browser Console:
Current count: 0
Current count: 0
Current count: 0
// Expected: 0, 1, 2
React DevTools - Components Tab:
- State shows correct value: count: 3
- But console logs show: 0, 0, 0
- Indicates closure captured old state
// TaskManager.tsx - WRONG: Stale closure in setTimeout
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
setTasks([...tasks, newTask]);
// Auto-save after 2 seconds
if (autoSaveEnabled) {
setTimeout(() => {
// BUG: This captures the current `tasks` array
// When setTimeout fires, it uses the OLD tasks array
console.log('Saving tasks:', tasks);
saveTasks(tasks); // Saves stale data!
}, 2000);
}
};
return (
<div>
<TaskInput onAdd={handleAddTask} />
<TaskList tasks={tasks} />
</div>
);
}
Root Cause
JavaScript closures capture variables from their surrounding scope at the time the function is created. When setTimeout creates its callback, it captures the current value of tasks. Even though tasks changes later, the callback still references the old array.
This is the closure trap: asynchronous operations (setTimeout, setInterval, event listeners, promises) capture state at creation time, not execution time.
The Trap
This feels like it should work because:
1. The state update happens immediately: setTasks([...tasks, newTask])
2. The setTimeout is created after the update
3. You expect it to "see" the new state
But React state updates are asynchronous, and the closure captures the value at the moment the function is defined, not when it executes.
The Fix: Use Functional Updates
When you need the latest state inside an async operation, use the functional form of setState:
// TaskManager.tsx - CORRECT: Functional update
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [autoSaveEnabled, setAutoSaveEnabled] = useState(true);
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
setTasks(prevTasks => [...prevTasks, newTask]);
if (autoSaveEnabled) {
setTimeout(() => {
// Use functional update to get latest state
setTasks(currentTasks => {
console.log('Saving tasks:', currentTasks);
saveTasks(currentTasks); // Always has latest data
return currentTasks; // No change, just reading
});
}, 2000);
}
};
return (
<div>
<TaskInput onAdd={handleAddTask} />
<TaskList tasks={tasks} />
</div>
);
}
Alternative: Use useRef for Reading Latest State
If you need to read state without triggering updates:
// TaskManager.tsx - Alternative: useRef
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const tasksRef = useRef(tasks);
// Keep ref in sync with state
useEffect(() => {
tasksRef.current = tasks;
}, [tasks]);
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
setTasks([...tasks, newTask]);
setTimeout(() => {
// tasksRef.current always has the latest value
console.log('Saving tasks:', tasksRef.current);
saveTasks(tasksRef.current);
}, 2000);
};
return (
<div>
<TaskInput onAdd={handleAddTask} />
<TaskList tasks={tasks} />
</div>
);
}
Prevention
- Default to functional updates when the new state depends on the old state
- Use useRef for values you need to read in async operations but don't need to trigger re-renders
- Avoid capturing state in setTimeout/setInterval—use refs or functional updates
- ESLint rule: Enable
react-hooks/exhaustive-depsto catch some closure issues
Pitfall 2: Mutating State Directly
Symptom
State appears to update in React DevTools, but the component doesn't re-render. Or the component re-renders, but the UI shows stale data. Arrays or objects seem to "lose" updates.
Browser Evidence
React DevTools - Components Tab:
- State value changes: tasks: Array(5) → tasks: Array(6)
- But component doesn't re-render
- Or renders but shows old data
Browser Console:
State before: [{ id: '1', title: 'Task 1', completed: false }]
State after: [{ id: '1', title: 'Task 1', completed: false }]
// Expected: completed: true
// TaskManager.tsx - WRONG: Direct mutation
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const handleToggleTask = (taskId: string) => {
// BUG: Mutating the array directly
const task = tasks.find(t => t.id === taskId);
if (task) {
task.completed = !task.completed; // Direct mutation!
setTasks(tasks); // Same reference, React won't re-render
}
};
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
// BUG: Mutating the array directly
tasks.push(newTask); // Direct mutation!
setTasks(tasks); // Same reference, React won't re-render
};
return (
<div>
<TaskInput onAdd={handleAddTask} />
<TaskList tasks={tasks} onToggle={handleToggleTask} />
</div>
);
}
Root Cause
React uses reference equality to detect state changes. When you call setTasks(tasks) with the same array reference, React thinks nothing changed and skips the re-render.
Even if you mutate the array's contents, the array reference itself is the same:
const arr = [1, 2, 3];
arr.push(4); // Mutates the array
console.log(arr); // [1, 2, 3, 4]
// But `arr` is still the same reference!
The Trap
This feels natural because: 1. In regular JavaScript, you mutate arrays and objects all the time 2. The mutation "works"—the data changes 3. React DevTools shows the new value 4. But React's reconciliation never triggers
The Fix: Create New References
Always create new arrays/objects when updating state:
// TaskManager.tsx - CORRECT: Immutable updates
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const handleToggleTask = (taskId: string) => {
// Create a new array with the updated task
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === taskId
? { ...task, completed: !task.completed } // New object
: task
)
);
};
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
// Create a new array with the new task
setTasks(prevTasks => [...prevTasks, newTask]);
};
const handleDeleteTask = (taskId: string) => {
// Create a new array without the deleted task
setTasks(prevTasks => prevTasks.filter(task => task.id !== taskId));
};
const handleUpdateTask = (taskId: string, updates: Partial<Task>) => {
// Create a new array with the updated task
setTasks(prevTasks =>
prevTasks.map(task =>
task.id === taskId
? { ...task, ...updates } // Merge updates into new object
: task
)
);
};
return (
<div>
<TaskInput onAdd={handleAddTask} />
<TaskList
tasks={tasks}
onToggle={handleToggleTask}
onDelete={handleDeleteTask}
onUpdate={handleUpdateTask}
/>
</div>
);
}
Common Immutable Update Patterns
Arrays:
// Add item
setItems([...items, newItem]);
setItems(items.concat(newItem));
// Remove item
setItems(items.filter(item => item.id !== idToRemove));
// Update item
setItems(items.map(item =>
item.id === idToUpdate
? { ...item, ...updates }
: item
));
// Insert at index
setItems([
...items.slice(0, index),
newItem,
...items.slice(index)
]);
// Replace at index
setItems(items.map((item, i) =>
i === index ? newItem : item
));
Objects:
// Update property
setUser({ ...user, name: 'New Name' });
// Update nested property
setUser({
...user,
address: {
...user.address,
city: 'New City'
}
});
// Add property
setUser({ ...user, newProp: 'value' });
// Remove property
const { propToRemove, ...rest } = user;
setUser(rest);
Prevention
- Never mutate state directly—always create new references
- Use spread operators (
...) for shallow copies - Use
.map(),.filter(),.concat()instead of.push(),.splice(),.pop() - For deep updates, consider Immer library (used by Redux Toolkit)
- TypeScript: Use
readonlyarrays to catch mutations at compile time
Pitfall 3: Batching Confusion and Multiple setState Calls
Symptom
Multiple state updates in the same function seem to "lose" some updates, or the component renders fewer times than expected. Or in React 18+, updates that used to batch now don't.
Browser Evidence
Browser Console:
Render count: 1
// Expected: 3 (one for each setState)
React DevTools - Profiler: - Shows 1 render instead of 3 - All state updates applied in single commit
// TaskManager.tsx - Understanding batching
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const [sortBy, setSortBy] = useState<'date' | 'priority'>('date');
const [renderCount, setRenderCount] = useState(0);
useEffect(() => {
setRenderCount(c => c + 1);
console.log('Render count:', renderCount);
});
const handleResetFilters = () => {
// In React 18+, these are automatically batched
setFilter('all');
setSortBy('date');
setTasks([]);
// Only 1 re-render, not 3!
};
const handleAsyncReset = async () => {
await fetchTasks(); // Async operation
// In React 17, these would NOT be batched (3 renders)
// In React 18+, these ARE batched (1 render)
setFilter('all');
setSortBy('date');
setTasks([]);
};
return (
<div>
<button onClick={handleResetFilters}>Reset Filters</button>
<button onClick={handleAsyncReset}>Async Reset</button>
<p>Render count: {renderCount}</p>
</div>
);
}
Root Cause
React 18+ Automatic Batching: React automatically batches multiple state updates into a single re-render, even in async functions, timeouts, and event handlers. This is a performance optimization.
React 17 Batching: Only batched updates inside React event handlers. Updates in promises, setTimeout, or native event handlers were not batched.
The Trap
This causes confusion when:
1. You expect each setState to trigger a separate render (for debugging or side effects)
2. You're migrating from React 17 and see different behavior
3. You have side effects that depend on intermediate state values
The Fix: Use flushSync for Immediate Updates (Rare)
If you truly need to force a synchronous update (very rare):
import { flushSync } from 'react-dom';
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const handleResetWithMeasurement = () => {
// Force synchronous update
flushSync(() => {
setFilter('all');
});
// This reads the updated DOM immediately
const height = document.getElementById('task-list')?.offsetHeight;
console.log('Height after filter reset:', height);
// This will be a separate render
flushSync(() => {
setTasks([]);
});
};
return (
<div>
<button onClick={handleResetWithMeasurement}>
Reset with Measurement
</button>
</div>
);
}
When Batching Causes Issues
Problem: You need to read DOM measurements between state updates.
Solution: Use flushSync (but this is a performance hit—avoid if possible).
Problem: You have a useEffect that depends on multiple state values and needs to run after each individual update.
Solution: Rethink your effect dependencies. Usually, you want the effect to run after all updates anyway.
Prevention
- Embrace batching—it's a performance win
- Don't rely on intermediate renders for side effects
- Use
flushSyncsparingly—only when you must read DOM between updates - In React 17, wrap async updates in
unstable_batchedUpdatesif needed
Pitfall 4: State Initialization with Expensive Computation
Symptom
Component initialization is slow. React DevTools Profiler shows long initial render time. The expensive computation runs on every render, not just the first one.
Browser Evidence
React DevTools - Profiler: - Initial render: 450ms - Subsequent renders: 440ms (still running expensive computation!) - Expected: Initial 450ms, subsequent <10ms
Browser Console:
Computing initial tasks... (runs on every render)
Computed 10000 tasks in 445ms
// TaskManager.tsx - WRONG: Expensive computation on every render
function TaskManager() {
// BUG: This function runs on EVERY render!
const [tasks, setTasks] = useState(generateInitialTasks(10000));
// Even though we only use the result once,
// generateInitialTasks(10000) executes every time TaskManager renders
return <TaskList tasks={tasks} />;
}
function generateInitialTasks(count: number): Task[] {
console.log('Computing initial tasks...');
const start = performance.now();
const tasks: Task[] = [];
for (let i = 0; i < count; i++) {
tasks.push({
id: crypto.randomUUID(),
title: `Task ${i}`,
completed: Math.random() > 0.5,
priority: ['low', 'medium', 'high'][Math.floor(Math.random() * 3)] as Task['priority'],
createdAt: new Date(Date.now() - Math.random() * 86400000),
});
}
console.log(`Computed ${count} tasks in ${performance.now() - start}ms`);
return tasks;
}
Root Cause
When you pass a value to useState, React evaluates that expression on every render. Even though React only uses the initial value once, the computation still runs.
const [state, setState] = useState(expensiveFunction());
// expensiveFunction() runs on EVERY render
The Trap
This looks correct because:
1. You know useState only uses the initial value once
2. The state updates correctly
3. But the performance cost is hidden until you profile
The Fix: Lazy Initialization
Pass a function to useState instead of a value:
// TaskManager.tsx - CORRECT: Lazy initialization
function TaskManager() {
// Pass a function, not a value
// The function only runs once, on mount
const [tasks, setTasks] = useState(() => generateInitialTasks(10000));
return <TaskList tasks={tasks} />;
}
function generateInitialTasks(count: number): Task[] {
console.log('Computing initial tasks...');
const start = performance.now();
const tasks: Task[] = [];
for (let i = 0; i < count; i++) {
tasks.push({
id: crypto.randomUUID(),
title: `Task ${i}`,
completed: Math.random() > 0.5,
priority: ['low', 'medium', 'high'][Math.floor(Math.random() * 3)] as Task['priority'],
createdAt: new Date(Date.now() - Math.random() * 86400000),
});
}
console.log(`Computed ${count} tasks in ${performance.now() - start}ms`);
return tasks;
}
Browser Console (after fix):
Computing initial tasks... (only on mount)
Computed 10000 tasks in 445ms
// No more logs on subsequent renders
When to Use Lazy Initialization
Use lazy initialization when: 1. Initial state requires expensive computation 2. Initial state comes from localStorage or other I/O 3. Initial state involves complex object creation
Don't bother when:
1. Initial state is a simple value: useState(0), useState('')
2. Initial state is a literal: useState([]), useState({})
Prevention
- Profile your components with React DevTools Profiler
- Use lazy initialization for any non-trivial computation
- ESLint rule: Create a custom rule to catch
useState(expensiveFunction())
useEffect Pitfalls
Pitfall 5: Missing Dependencies in useEffect
Symptom
Effect uses stale values. Component doesn't update when expected. Effect doesn't re-run when it should. ESLint warning: "React Hook useEffect has a missing dependency."
Browser Evidence
Browser Console:
Effect running with filter: all
// User changes filter to "active"
// Effect doesn't re-run, still uses "all"
React DevTools - Components Tab:
- State shows: filter: "active"
- But effect behavior suggests: filter: "all"
ESLint Warning:
React Hook useEffect has a missing dependency: 'filter'.
Either include it or remove the dependency array.
// TaskManager.tsx - WRONG: Missing dependency
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
useEffect(() => {
// BUG: Effect uses `filter` but doesn't list it in dependencies
const filtered = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
console.log('Filtered tasks:', filtered);
updateUI(filtered);
}, [tasks]); // Missing `filter`!
return (
<div>
<TaskFilters filter={filter} onFilterChange={setFilter} />
<TaskList tasks={tasks} />
</div>
);
}
Root Cause
Effects capture values from their surrounding scope (closure). When dependencies are missing, the effect uses stale values from the render when it was created.
React's dependency array tells React when to re-run the effect. If you omit a dependency, React won't re-run the effect when that value changes.
The Trap
You might omit dependencies because:
1. You want the effect to run only once (use [] instead)
2. You want to avoid infinite loops (fix the root cause instead)
3. ESLint warning seems wrong (it's usually right)
4. You think React will "figure it out" (it won't)
The Fix: Include All Dependencies
Always include every value from the component scope that the effect uses:
// TaskManager.tsx - CORRECT: All dependencies included
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
useEffect(() => {
const filtered = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
console.log('Filtered tasks:', filtered);
updateUI(filtered);
}, [tasks, filter]); // Both dependencies included
return (
<div>
<TaskFilters filter={filter} onFilterChange={setFilter} />
<TaskList tasks={tasks} />
</div>
);
}
When Dependencies Cause Infinite Loops
If including a dependency causes an infinite loop, the problem is not the dependency—it's that your effect is creating a new reference on every render:
// WRONG: Infinite loop
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
// This object is created on every render (new reference)
const config = { sortBy: 'date', order: 'asc' };
useEffect(() => {
const sorted = sortTasks(tasks, config);
setTasks(sorted); // Updates tasks
}, [tasks, config]); // config changes every render → infinite loop!
return <TaskList tasks={tasks} />;
}
Fix 1: Move the object inside the effect:
// CORRECT: Object created inside effect
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
const config = { sortBy: 'date', order: 'asc' };
const sorted = sortTasks(tasks, config);
setTasks(sorted);
}, [tasks]); // No config dependency needed
return <TaskList tasks={tasks} />;
}
Fix 2: Use useMemo to stabilize the reference:
// CORRECT: Stable reference with useMemo
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const config = useMemo(
() => ({ sortBy: 'date', order: 'asc' }),
[] // Only create once
);
useEffect(() => {
const sorted = sortTasks(tasks, config);
setTasks(sorted);
}, [tasks, config]); // config is stable now
return <TaskList tasks={tasks} />;
}
Fix 3: Use primitive values instead of objects:
// CORRECT: Primitive dependencies
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const sortBy = 'date';
const order = 'asc';
useEffect(() => {
const sorted = sortTasks(tasks, { sortBy, order });
setTasks(sorted);
}, [tasks, sortBy, order]); // Primitives are stable
return <TaskList tasks={tasks} />;
}
Prevention
- Always include all dependencies—trust ESLint
- Use
eslint-plugin-react-hooksand don't disable the rule - If you get an infinite loop, fix the root cause (unstable references)
- Move objects/functions inside effects when possible
- Use useMemo/useCallback to stabilize references when necessary
Pitfall 6: Effects That Should Be Event Handlers
Symptom
Effect runs on every render or at unexpected times. Logic that should run in response to user action runs automatically. Unnecessary API calls or side effects.
Browser Evidence
Browser Console:
Saving tasks... (runs on mount)
Saving tasks... (runs on every task change)
Saving tasks... (runs when filter changes)
// Expected: Only when user clicks "Save"
Network Tab: - POST /api/tasks (on mount) - POST /api/tasks (on every keystroke) - POST /api/tasks (on filter change) - Expected: Only on explicit save action
// TaskManager.tsx - WRONG: Effect for user action
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [shouldSave, setShouldSave] = useState(false);
// BUG: Using effect for something that should be an event handler
useEffect(() => {
if (shouldSave) {
console.log('Saving tasks...');
saveTasks(tasks);
setShouldSave(false);
}
}, [shouldSave, tasks]);
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
setTasks([...tasks, newTask]);
setShouldSave(true); // Trigger effect
};
return (
<div>
<TaskInput onAdd={handleAddTask} />
<button onClick={() => setShouldSave(true)}>Save</button>
</div>
);
}
Root Cause
Effects are for synchronizing with external systems (APIs, DOM, subscriptions), not for responding to user actions. When you use an effect for user-triggered logic, you're fighting React's model.
The effect runs: 1. On mount (usually not desired) 2. Whenever dependencies change (too often) 3. Not in direct response to the user action (confusing)
The Trap
You might use effects for user actions because: 1. You want to "trigger" logic from multiple places 2. You're used to imperative programming (do X, then Y) 3. You think effects are "the React way" to do side effects 4. You want to avoid passing callbacks through props
The Fix: Use Event Handlers
Put the logic directly in the event handler:
// TaskManager.tsx - CORRECT: Event handler for user action
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const handleSave = async () => {
console.log('Saving tasks...');
try {
await saveTasks(tasks);
toast.success('Tasks saved!');
} catch (error) {
toast.error('Failed to save tasks');
}
};
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(),
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
setTasks([...tasks, newTask]);
// Don't auto-save, let user decide
};
return (
<div>
<TaskInput onAdd={handleAddTask} />
<button onClick={handleSave}>Save</button>
</div>
);
}
When You Actually Need Auto-Save
If you truly want auto-save (save after every change), use a debounced effect:
// TaskManager.tsx - Auto-save with debounce
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
// Debounce: only save after user stops editing for 2 seconds
const timeoutId = setTimeout(() => {
console.log('Auto-saving tasks...');
saveTasks(tasks);
}, 2000);
// Cleanup: cancel the timeout if tasks change again
return () => clearTimeout(timeoutId);
}, [tasks]);
return <TaskInput onAdd={handleAddTask} />;
}
Decision Framework: Effect vs. Event Handler
Use an event handler when: - Logic runs in response to a specific user action - Logic should run exactly once per action - Logic is part of the user's intent (save, submit, delete)
Use an effect when: - Synchronizing with an external system (WebSocket, DOM, browser API) - Logic should run whenever certain data changes - Logic is about keeping things in sync, not responding to actions
Prevention
- Default to event handlers for user actions
- Use effects for synchronization only
- Ask: "Is this responding to a user action or synchronizing state?"
- If you need auto-save, use debouncing
Pitfall 7: Forgetting Effect Cleanup
Symptom
Memory leaks. "Can't perform a React state update on an unmounted component" warning. Subscriptions or timers continue after component unmounts. Stale data from old requests.
Browser Evidence
Browser Console:
Warning: Can't perform a React state update on an unmounted component.
This is a no-op, but it indicates a memory leak in your application.
To fix, cancel all subscriptions and asynchronous tasks in a useEffect cleanup function.
Network Tab: - Multiple requests to /api/tasks - Component unmounts - Old requests complete and try to update state - Warning appears
Memory Profiler: - Memory usage increases over time - Event listeners not removed - Timers still running
// TaskManager.tsx - WRONG: No cleanup
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
// BUG: No cleanup for interval
const intervalId = setInterval(() => {
fetchTasks().then(setTasks);
}, 5000);
// BUG: No cleanup for event listener
window.addEventListener('online', () => {
fetchTasks().then(setTasks);
});
// BUG: No cleanup for fetch
fetchTasks().then(setTasks);
// Missing cleanup function!
}, []);
return <TaskList tasks={tasks} />;
}
Root Cause
When a component unmounts, any ongoing async operations (timers, subscriptions, fetch requests) continue running. If they try to update state after unmount, React warns about memory leaks.
Effects need cleanup to: 1. Cancel timers and intervals 2. Remove event listeners 3. Unsubscribe from subscriptions 4. Abort fetch requests 5. Close WebSocket connections
The Trap
You forget cleanup because: 1. The component works fine initially 2. The warning only appears when navigating away 3. You don't test unmounting scenarios 4. You think React will "clean up automatically" (it won't)
The Fix: Return a Cleanup Function
Always return a cleanup function from effects that create subscriptions or timers:
// TaskManager.tsx - CORRECT: Proper cleanup
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
let isMounted = true; // Track mount status
// Set up interval
const intervalId = setInterval(() => {
fetchTasks().then(data => {
if (isMounted) setTasks(data);
});
}, 5000);
// Set up event listener
const handleOnline = () => {
fetchTasks().then(data => {
if (isMounted) setTasks(data);
});
};
window.addEventListener('online', handleOnline);
// Initial fetch with AbortController
const abortController = new AbortController();
fetchTasks({ signal: abortController.signal })
.then(data => {
if (isMounted) setTasks(data);
})
.catch(error => {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
}
});
// Cleanup function
return () => {
isMounted = false;
clearInterval(intervalId);
window.removeEventListener('online', handleOnline);
abortController.abort();
};
}, []);
return <TaskList tasks={tasks} />;
}
Cleanup Patterns for Common Scenarios
Timers:
useEffect(() => {
const timeoutId = setTimeout(() => {
// Do something
}, 1000);
return () => clearTimeout(timeoutId);
}, []);
useEffect(() => {
const intervalId = setInterval(() => {
// Do something repeatedly
}, 1000);
return () => clearInterval(intervalId);
}, []);
Event Listeners:
useEffect(() => {
const handleResize = () => {
// Handle resize
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
Fetch Requests:
useEffect(() => {
const abortController = new AbortController();
fetch('/api/data', { signal: abortController.signal })
.then(res => res.json())
.then(data => setData(data))
.catch(error => {
if (error.name !== 'AbortError') {
console.error(error);
}
});
return () => abortController.abort();
}, []);
Subscriptions (WebSocket, Firebase, etc.):
useEffect(() => {
const unsubscribe = subscribeToTasks(tasks => {
setTasks(tasks);
});
return unsubscribe;
}, []);
Prevention
- Always return cleanup from effects with subscriptions/timers
- Use AbortController for fetch requests
- Test unmounting scenarios in development
- Use React Query for data fetching (handles cleanup automatically)
- ESLint rule: Create custom rule to catch missing cleanup
Pitfall 8: Running Effects on Every Render
Symptom
Effect runs constantly. Performance degrades. Infinite loops. Console flooded with logs. Network tab shows repeated requests.
Browser Evidence
Browser Console:
Effect running...
Effect running...
Effect running...
(repeats infinitely)
React DevTools - Profiler: - Component renders continuously - Each render triggers effect - Effect triggers state update - State update triggers render - Infinite loop
Network Tab: - Hundreds of requests per second - Browser becomes unresponsive
// TaskManager.tsx - WRONG: Effect runs on every render
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [sortedTasks, setSortedTasks] = useState<Task[]>([]);
// BUG: No dependency array → runs on every render
useEffect(() => {
console.log('Sorting tasks...');
const sorted = [...tasks].sort((a, b) =>
a.createdAt.getTime() - b.createdAt.getTime()
);
setSortedTasks(sorted); // Triggers re-render
}); // Missing dependency array!
return <TaskList tasks={sortedTasks} />;
}
Root Cause
Without a dependency array, effects run after every render. If the effect updates state, it triggers another render, which runs the effect again, creating an infinite loop.
Three forms of useEffect:
-
No dependency array: Runs after every render
typescript useEffect(() => { // Runs after every render }); -
Empty dependency array: Runs once on mount
typescript useEffect(() => { // Runs once on mount }, []); -
With dependencies: Runs when dependencies change
typescript useEffect(() => { // Runs when dep1 or dep2 changes }, [dep1, dep2]);
The Trap
You omit the dependency array because: 1. You forget to add it 2. You don't understand the difference 3. You want it to run "all the time" (wrong approach)
The Fix: Add Dependency Array
Specify when the effect should run:
// TaskManager.tsx - CORRECT: Effect runs when tasks change
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [sortedTasks, setSortedTasks] = useState<Task[]>([]);
useEffect(() => {
console.log('Sorting tasks...');
const sorted = [...tasks].sort((a, b) =>
a.createdAt.getTime() - b.createdAt.getTime()
);
setSortedTasks(sorted);
}, [tasks]); // Only run when tasks changes
return <TaskList tasks={sortedTasks} />;
}
Better: Don't Use an Effect at All
For derived state (computed from existing state), don't use an effect—compute during render:
// TaskManager.tsx - BEST: No effect needed
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
// Compute during render, no effect needed
const sortedTasks = useMemo(
() => [...tasks].sort((a, b) =>
a.createdAt.getTime() - b.createdAt.getTime()
),
[tasks]
);
return <TaskList tasks={sortedTasks} />;
}
When to Use Each Pattern
No dependency array (almost never):
useEffect(() => {
// Runs after every render
// Use case: Debugging, logging (remove in production)
});
Empty dependency array (mount only):
useEffect(() => {
// Runs once on mount
// Use case: Initial data fetch, subscriptions, one-time setup
}, []);
With dependencies (most common):
useEffect(() => {
// Runs when dependencies change
// Use case: Sync with external system when data changes
}, [dep1, dep2]);
Prevention
- Always include dependency array (even if empty)
- For derived state, use useMemo instead of useEffect
- ESLint rule:
react-hooks/exhaustive-depscatches missing arrays - Ask: "Does this need to be an effect at all?"
Component Rendering Pitfalls
Pitfall 9: Creating Components Inside Components
Symptom
Component loses state on every render. Input fields lose focus while typing. Animations restart. React DevTools shows component unmounting and remounting constantly.
Browser Evidence
Browser Console:
TaskItem mounted
TaskItem unmounted
TaskItem mounted
TaskItem unmounted
(repeats on every keystroke)
React DevTools - Components Tab: - Component tree shows TaskItem disappearing and reappearing - State resets to initial values - Keys change on every render
User Experience: - Typing in input loses focus after each character - Checkboxes uncheck themselves - Animations restart mid-way
// TaskManager.tsx - WRONG: Component defined inside component
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
// BUG: Component defined inside render
function TaskItem({ task }: { task: Task }) {
const [isEditing, setIsEditing] = useState(false);
return (
<div>
{isEditing ? (
<input defaultValue={task.title} />
) : (
<span>{task.title}</span>
)}
<button onClick={() => setIsEditing(!isEditing)}>
Edit
</button>
</div>
);
}
return (
<div>
{tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
Root Cause
When you define a component inside another component, React creates a new component type on every render. React sees TaskItem as a different component each time, so it unmounts the old one and mounts a new one, losing all state.
// Render 1: TaskItem is function A
function TaskItem() { ... }
// Render 2: TaskItem is function B (different reference!)
function TaskItem() { ... }
// React thinks: "This is a different component, unmount A and mount B"
The Trap
You define components inside components because: 1. You want to access parent state without passing props 2. You think it's "scoped" or "private" 3. You're used to nested functions in regular JavaScript 4. It seems convenient
The Fix: Define Components Outside
Always define components at the top level:
// TaskItem.tsx - CORRECT: Component defined outside
interface TaskItemProps {
task: Task;
onUpdate: (id: string, updates: Partial<Task>) => void;
}
function TaskItem({ task, onUpdate }: TaskItemProps) {
const [isEditing, setIsEditing] = useState(false);
return (
<div>
{isEditing ? (
<input
defaultValue={task.title}
onBlur={(e) => {
onUpdate(task.id, { title: e.target.value });
setIsEditing(false);
}}
/>
) : (
<span>{task.title}</span>
)}
<button onClick={() => setIsEditing(!isEditing)}>
Edit
</button>
</div>
);
}
// TaskManager.tsx
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const handleUpdateTask = (id: string, updates: Partial<Task>) => {
setTasks(tasks.map(task =>
task.id === id ? { ...task, ...updates } : task
));
};
return (
<div>
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onUpdate={handleUpdateTask}
/>
))}
</div>
);
}
If You Need to Access Parent State
Pass it as props or use context:
// Using context for deeply nested access
const TaskContext = createContext<{
tasks: Task[];
updateTask: (id: string, updates: Partial<Task>) => void;
} | null>(null);
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const updateTask = (id: string, updates: Partial<Task>) => {
setTasks(tasks.map(task =>
task.id === id ? { ...task, ...updates } : task
));
};
return (
<TaskContext.Provider value={{ tasks, updateTask }}>
<TaskList />
</TaskContext.Provider>
);
}
// TaskItem can now access context without prop drilling
function TaskItem({ task }: { task: Task }) {
const context = useContext(TaskContext);
if (!context) throw new Error('TaskItem must be used within TaskContext');
const [isEditing, setIsEditing] = useState(false);
return (
<div>
{isEditing ? (
<input
defaultValue={task.title}
onBlur={(e) => {
context.updateTask(task.id, { title: e.target.value });
setIsEditing(false);
}}
/>
) : (
<span>{task.title}</span>
)}
</div>
);
}
Prevention
- Never define components inside components
- Define components at module level (top of file)
- Use props or context to share state
- ESLint rule:
react/no-unstable-nested-componentscatches this
Pitfall 10: Using Index as Key
Symptom
Wrong items get updated or deleted. Component state gets mixed up. Animations apply to wrong items. Checkboxes check wrong items after reordering.
Browser Evidence
User Experience: 1. User checks checkbox on "Task 1" 2. User deletes "Task 1" 3. Checkbox appears checked on "Task 2" (wrong item!)
React DevTools - Components Tab:
- Keys are: 0, 1, 2, 3
- After deletion: 0, 1, 2 (same keys, different items)
- React reuses components with same keys
// TaskManager.tsx - WRONG: Index as key
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([
{ id: '1', title: 'Task 1', completed: false, priority: 'high', createdAt: new Date() },
{ id: '2', title: 'Task 2', completed: false, priority: 'medium', createdAt: new Date() },
{ id: '3', title: 'Task 3', completed: false, priority: 'low', createdAt: new Date() },
]);
const handleDeleteTask = (index: number) => {
setTasks(tasks.filter((_, i) => i !== index));
};
return (
<div>
{tasks.map((task, index) => (
// BUG: Using index as key
<TaskItem
key={index}
task={task}
onDelete={() => handleDeleteTask(index)}
/>
))}
</div>
);
}
function TaskItem({ task, onDelete }: { task: Task; onDelete: () => void }) {
const [isChecked, setIsChecked] = useState(false);
return (
<div>
<input
type="checkbox"
checked={isChecked}
onChange={(e) => setIsChecked(e.target.checked)}
/>
<span>{task.title}</span>
<button onClick={onDelete}>Delete</button>
</div>
);
}
Root Cause
React uses keys to identify which items have changed, been added, or been removed. When you use index as key:
Before deletion:
key=0 → Task 1 (checkbox: unchecked)
key=1 → Task 2 (checkbox: checked)
key=2 → Task 3 (checkbox: unchecked)
After deleting Task 1:
key=0 → Task 2 (React reuses key=0 component, keeps its state: unchecked)
key=1 → Task 3 (React reuses key=1 component, keeps its state: checked)
React thinks: - "key=0 is still here, just update its props" - "key=1 is still here, just update its props" - "key=2 is gone, unmount it"
But the component state (checkbox) stays with the key, not the data!
The Trap
You use index as key because: 1. It's the easiest thing to reach for 2. It "works" when you don't reorder/delete items 3. You don't understand what keys are for 4. ESLint doesn't warn (it only warns about missing keys)
The Fix: Use Stable, Unique IDs
Always use a stable, unique identifier from your data:
// TaskManager.tsx - CORRECT: Stable ID as key
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([
{ id: '1', title: 'Task 1', completed: false, priority: 'high', createdAt: new Date() },
{ id: '2', title: 'Task 2', completed: false, priority: 'medium', createdAt: new Date() },
{ id: '3', title: 'Task 3', completed: false, priority: 'low', createdAt: new Date() },
]);
const handleDeleteTask = (id: string) => {
setTasks(tasks.filter(task => task.id !== id));
};
return (
<div>
{tasks.map(task => (
// CORRECT: Using stable ID as key
<TaskItem
key={task.id}
task={task}
onDelete={() => handleDeleteTask(task.id)}
/>
))}
</div>
);
}
When Index as Key is Acceptable
Index as key is safe only when ALL of these are true: 1. The list is static (never reordered, added to, or removed from) 2. Items have no state 3. Items have no animations
Example of safe usage:
// Safe: Static list, no state, no reordering
function ColorPalette() {
const colors = ['red', 'blue', 'green']; // Never changes
return (
<div>
{colors.map((color, index) => (
<div key={index} style={{ backgroundColor: color }}>
{color}
</div>
))}
</div>
);
}
Generating Stable IDs
If your data doesn't have IDs, generate them once:
// Generate IDs when creating items
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const handleAddTask = (title: string) => {
const newTask: Task = {
id: crypto.randomUUID(), // Generate stable ID
title,
completed: false,
priority: 'medium',
createdAt: new Date(),
};
setTasks([...tasks, newTask]);
};
return (
<div>
{tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
Prevention
- Always use stable, unique IDs as keys
- Never use index as key unless the list is truly static
- Generate IDs when creating items if they don't have them
- Use
crypto.randomUUID()or a library likenanoid - Test reordering and deletion to catch key issues
Pitfall 11: Unnecessary Re-renders from Object/Array Props
Symptom
Child component re-renders even though its props "haven't changed." Performance degrades with many child components. React DevTools Profiler shows unnecessary renders.
Browser Evidence
React DevTools - Profiler: - Parent renders - All children render (highlighted in yellow) - Props appear identical in Components tab - But references are different
Console logs:
Parent render
Child render (props: { filter: 'all', sort: 'date' })
Parent render
Child render (props: { filter: 'all', sort: 'date' })
// Props look the same, but child still re-renders
// TaskManager.tsx - WRONG: New object on every render
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filterValue, setFilterValue] = useState('all');
const [sortValue, setSortValue] = useState('date');
return (
<div>
{/* BUG: New object created on every render */}
<TaskFilters
config={{ filter: filterValue, sort: sortValue }}
onConfigChange={(config) => {
setFilterValue(config.filter);
setSortValue(config.sort);
}}
/>
{/* BUG: New array created on every render */}
<TaskList
tasks={tasks}
priorities={['low', 'medium', 'high']}
/>
</div>
);
}
// Child component with React.memo
const TaskFilters = React.memo(function TaskFilters({
config,
onConfigChange
}: {
config: { filter: string; sort: string };
onConfigChange: (config: { filter: string; sort: string }) => void;
}) {
console.log('TaskFilters render');
return <div>{/* ... */}</div>;
});
Root Cause
JavaScript objects and arrays are compared by reference, not by value. Even if two objects have identical contents, they're different if they're different instances:
const obj1 = { filter: 'all', sort: 'date' };
const obj2 = { filter: 'all', sort: 'date' };
obj1 === obj2 // false! Different references
When you create a new object/array on every render, React.memo sees it as a "different" prop and re-renders the child.
The Trap
You create new objects/arrays because:
1. It's convenient: config={{ filter, sort }}
2. You don't realize it creates a new reference
3. You think React.memo will "figure it out" (it won't)
4. You don't profile to see the unnecessary renders
The Fix: Stabilize References with useMemo
Use useMemo to create stable references:
// TaskManager.tsx - CORRECT: Stable references
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filterValue, setFilterValue] = useState('all');
const [sortValue, setSortValue] = useState('date');
// Stable config object
const config = useMemo(
() => ({ filter: filterValue, sort: sortValue }),
[filterValue, sortValue]
);
// Stable callback
const handleConfigChange = useCallback(
(newConfig: { filter: string; sort: string }) => {
setFilterValue(newConfig.filter);
setSortValue(newConfig.sort);
},
[]
);
// Stable array (only create once)
const priorities = useMemo(
() => ['low', 'medium', 'high'] as const,
[]
);
return (
<div>
<TaskFilters
config={config}
onConfigChange={handleConfigChange}
/>
<TaskList
tasks={tasks}
priorities={priorities}
/>
</div>
);
}
Alternative: Pass Primitive Values
Instead of objects, pass primitive values:
// TaskManager.tsx - Alternative: Primitives
function TaskManager() {
const [filterValue, setFilterValue] = useState('all');
const [sortValue, setSortValue] = useState('date');
return (
<div>
{/* Primitives are compared by value, not reference */}
<TaskFilters
filter={filterValue}
sort={sortValue}
onFilterChange={setFilterValue}
onSortChange={setSortValue}
/>
</div>
);
}
const TaskFilters = React.memo(function TaskFilters({
filter,
sort,
onFilterChange,
onSortChange
}: {
filter: string;
sort: string;
onFilterChange: (filter: string) => void;
onSortChange: (sort: string) => void;
}) {
console.log('TaskFilters render');
return <div>{/* ... */}</div>;
});
When to Optimize
Don't optimize prematurely. Only use useMemo/useCallback when: 1. Profiling shows unnecessary re-renders 2. Child component is expensive to render 3. Child component is memoized with React.memo 4. List has many items (50+)
Prevention
- Profile first with React DevTools Profiler
- Pass primitives when possible
- Use useMemo for objects/arrays passed to memoized components
- Use useCallback for functions passed to memoized components
- Don't optimize until you have evidence of a problem
TypeScript Pitfalls
Pitfall 12: Type Assertions Hiding Bugs
Symptom
Runtime errors that TypeScript should have caught. "Cannot read property of undefined" errors. Type errors in production but not development.
Browser Evidence
Browser Console:
Uncaught TypeError: Cannot read properties of undefined (reading 'title')
at TaskItem.tsx:15
TypeScript Compiler:
No errors found.
The disconnect: TypeScript thinks the code is safe, but it crashes at runtime.
// TaskManager.tsx - WRONG: Type assertion hiding bug
interface Task {
id: string;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: Date;
}
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
fetch('/api/tasks')
.then(res => res.json())
.then(data => {
// BUG: Type assertion without validation
setTasks(data as Task[]);
});
}, []);
return (
<div>
{tasks.map(task => (
<div key={task.id}>
{/* Runtime error if task.title is undefined */}
<h3>{task.title.toUpperCase()}</h3>
</div>
))}
</div>
);
}
Root Cause
Type assertions (as Type) tell TypeScript "trust me, I know this is the right type." But TypeScript doesn't verify this—it just believes you. If the runtime data doesn't match the asserted type, you get runtime errors.
const data = { id: '1', name: 'Task' }; // Missing 'title'
const task = data as Task; // TypeScript: "OK, it's a Task"
console.log(task.title.toUpperCase()); // Runtime error!
The Trap
You use type assertions because:
1. TypeScript complains and you want it to stop
2. You "know" the data is correct
3. You're dealing with external data (API, localStorage)
4. You think as is the same as validation (it's not)
The Fix: Validate at Runtime
Use runtime validation with a library like Zod:
// TaskManager.tsx - CORRECT: Runtime validation
import { z } from 'zod';
// Define schema that matches your type
const TaskSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
priority: z.enum(['low', 'medium', 'high']),
createdAt: z.coerce.date(), // Coerce string to Date
});
const TaskArraySchema = z.array(TaskSchema);
type Task = z.infer<typeof TaskSchema>;
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch('/api/tasks')
.then(res => res.json())
.then(data => {
// Validate at runtime
const result = TaskArraySchema.safeParse(data);
if (result.success) {
setTasks(result.data);
} else {
console.error('Invalid task data:', result.error);
setError('Failed to load tasks: invalid data format');
}
})
.catch(err => {
console.error('Fetch error:', err);
setError('Failed to load tasks');
});
}, []);
if (error) {
return <div>Error: {error}</div>;
}
return (
<div>
{tasks.map(task => (
<div key={task.id}>
{/* Safe: TypeScript AND runtime guarantee title exists */}
<h3>{task.title.toUpperCase()}</h3>
</div>
))}
</div>
);
}
Alternative: Type Guards
For simpler cases, write type guard functions:
// Type guard function
function isTask(value: unknown): value is Task {
return (
typeof value === 'object' &&
value !== null &&
'id' in value &&
typeof value.id === 'string' &&
'title' in value &&
typeof value.title === 'string' &&
'completed' in value &&
typeof value.completed === 'boolean' &&
'priority' in value &&
['low', 'medium', 'high'].includes(value.priority as string) &&
'createdAt' in value &&
value.createdAt instanceof Date
);
}
function isTaskArray(value: unknown): value is Task[] {
return Array.isArray(value) && value.every(isTask);
}
// Usage
fetch('/api/tasks')
.then(res => res.json())
.then(data => {
if (isTaskArray(data)) {
setTasks(data); // TypeScript knows it's Task[]
} else {
setError('Invalid task data');
}
});
When Type Assertions Are Acceptable
Type assertions are safe when:
1. You're narrowing a type you already know: event.target as HTMLInputElement
2. You're working with DOM APIs: document.getElementById('root') as HTMLDivElement
3. You're dealing with library types that are too strict
But even then, prefer type guards when possible.
Prevention
- Never use
aswith external data (API, localStorage, user input) - Use Zod or similar for runtime validation
- Write type guards for complex validation logic
- Prefer
unknownoveranyto force validation - ESLint rule:
@typescript-eslint/consistent-type-assertionsto restrictas
Pitfall 13: The any Escape Hatch
Symptom
Type errors that should be caught at compile time appear at runtime. Autocomplete stops working. Refactoring breaks things silently.
Browser Evidence
Browser Console:
Uncaught TypeError: task.toggleComplete is not a function
at TaskItem.tsx:23
TypeScript Compiler:
No errors found.
VS Code:
- No autocomplete for task properties
- No error when calling non-existent method
// TaskManager.tsx - WRONG: any disables type checking
function TaskManager() {
const [tasks, setTasks] = useState<any[]>([]); // BUG: any
useEffect(() => {
fetch('/api/tasks')
.then(res => res.json())
.then((data: any) => { // BUG: any
setTasks(data);
});
}, []);
const handleToggle = (task: any) => { // BUG: any
// TypeScript doesn't catch this error
task.toggleComplete(); // Runtime error: method doesn't exist
};
return (
<div>
{tasks.map((task: any) => ( // BUG: any
<div key={task.id}>
<h3>{task.title}</h3>
<button onClick={() => handleToggle(task)}>Toggle</button>
</div>
))}
</div>
);
}
Root Cause
any disables all type checking. TypeScript treats any as "I don't care about types here." This defeats the entire purpose of TypeScript.
const task: any = { id: '1', title: 'Task' };
task.title.toUpperCase(); // OK
task.nonExistent.method(); // OK (but crashes at runtime!)
task.anything.you.want.here(); // OK (but crashes at runtime!)
The Trap
You use any because:
1. TypeScript errors are frustrating
2. You don't know the correct type
3. You're dealing with complex third-party types
4. You think "I'll fix it later" (you won't)
The Fix: Use Proper Types
Define proper types for your data:
// TaskManager.tsx - CORRECT: Proper types
interface Task {
id: string;
title: string;
completed: boolean;
priority: 'low' | 'medium' | 'high';
createdAt: Date;
}
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
useEffect(() => {
fetch('/api/tasks')
.then(res => res.json())
.then((data: unknown) => { // Use unknown, not any
const result = TaskArraySchema.safeParse(data);
if (result.success) {
setTasks(result.data);
}
});
}, []);
const handleToggle = (task: Task) => {
// TypeScript catches this error at compile time
task.toggleComplete(); // Error: Property 'toggleComplete' does not exist
// Correct approach
setTasks(tasks.map(t =>
t.id === task.id
? { ...t, completed: !t.completed }
: t
));
};
return (
<div>
{tasks.map(task => (
<div key={task.id}>
<h3>{task.title}</h3>
<button onClick={() => handleToggle(task)}>Toggle</button>
</div>
))}
</div>
);
}
Use unknown Instead of any
When you truly don't know the type, use unknown:
// WRONG: any allows anything
function processData(data: any) {
return data.value.toUpperCase(); // No error, crashes at runtime
}
// CORRECT: unknown forces validation
function processData(data: unknown) {
// Error: Object is of type 'unknown'
return data.value.toUpperCase();
// Must validate first
if (
typeof data === 'object' &&
data !== null &&
'value' in data &&
typeof data.value === 'string'
) {
return data.value.toUpperCase(); // OK now
}
throw new Error('Invalid data');
}
When any is Acceptable (Rarely)
any is acceptable when:
1. Migrating JavaScript to TypeScript: Use any temporarily, add // TODO: type this comment
2. Dealing with truly dynamic data: JSON.parse result (but validate immediately)
3. Working around library bugs: When library types are wrong (but file an issue)
Prevention
- Ban
anyin your codebase: Use ESLint rule@typescript-eslint/no-explicit-any - Use
unknownwhen you don't know the type - Define proper interfaces for your data
- Use Zod to generate types from schemas
- Gradually type existing code instead of using
any
Next.js-Specific Pitfalls
Pitfall 14: Using Client-Only APIs in Server Components
Symptom
Build fails with cryptic errors. "window is not defined" or "document is not defined" errors. Component works in development but fails in production build.
Browser Evidence
Terminal Output (during build):
Error: Unhandled Runtime Error
ReferenceError: window is not defined
Call Stack:
TaskManager.tsx (15:23)
Development: Works fine Production build: Fails
// app/tasks/page.tsx - WRONG: Client API in Server Component
export default function TasksPage() {
// BUG: Server Components can't access window
const savedFilter = window.localStorage.getItem('taskFilter');
// BUG: Server Components can't access document
const theme = document.body.classList.contains('dark') ? 'dark' : 'light';
return (
<div>
<h1>Tasks</h1>
<p>Filter: {savedFilter}</p>
<p>Theme: {theme}</p>
</div>
);
}
Root Cause
Next.js App Router uses Server Components by default. Server Components render on the server (Node.js), which doesn't have browser APIs like window, document, localStorage, etc.
In development, Next.js might render on the client for fast refresh, hiding the issue. In production, it fails during build.
The Trap
You use browser APIs because: 1. You forget you're in a Server Component 2. You're used to client-only React 3. Development works fine (false confidence) 4. The error message is confusing
The Fix: Use Client Components
Add 'use client' directive for components that need browser APIs:
// app/tasks/page.tsx - Server Component (no browser APIs)
import { TaskList } from './TaskList';
export default function TasksPage() {
// Server Component: Can fetch data, but no browser APIs
const tasks = await fetchTasks();
return (
<div>
<h1>Tasks</h1>
<TaskList initialTasks={tasks} />
</div>
);
}
// components/TaskList.tsx - Client Component
'use client';
import { useState, useEffect } from 'react';
export function TaskList({ initialTasks }: { initialTasks: Task[] }) {
const [tasks, setTasks] = useState(initialTasks);
const [filter, setFilter] = useState('all');
useEffect(() => {
// Client Component: Can use browser APIs
const savedFilter = localStorage.getItem('taskFilter');
if (savedFilter) {
setFilter(savedFilter);
}
}, []);
useEffect(() => {
localStorage.setItem('taskFilter', filter);
}, [filter]);
return (
<div>
{/* Client-side interactivity */}
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
{tasks.map(task => (
<div key={task.id}>{task.title}</div>
))}
</div>
);
}
Decision Framework: Server vs. Client Component
Use Server Component when: - Fetching data from database/API - Accessing backend resources - Keeping sensitive logic on server - Reducing client bundle size - No interactivity needed
Use Client Component when: - Using browser APIs (localStorage, window, document) - Using React hooks (useState, useEffect, useContext) - Handling user interactions (onClick, onChange) - Using browser-only libraries
Prevention
- Default to Server Components in App Router
- Add
'use client'only when needed - Check build output for errors before deploying
- Use
typeof window !== 'undefined'for conditional browser code - Test production builds locally with
npm run build && npm start
Pitfall 15: Hydration Mismatches
Symptom
Warning in console: "Hydration failed because the initial UI does not match what was rendered on the server." Content flashes or changes after page load. Styles apply incorrectly initially.
Browser Evidence
Browser Console:
Warning: Hydration failed because the initial UI does not match
what was rendered on the server.
Warning: Expected server HTML to contain a matching <div> in <div>.
User Experience: - Page loads with one layout - Suddenly shifts to different layout - Flash of unstyled content - Cumulative Layout Shift (CLS) issues
// app/tasks/page.tsx - WRONG: Hydration mismatch
export default function TasksPage() {
return (
<div>
<h1>Tasks</h1>
{/* BUG: Different content on server vs. client */}
<p>Current time: {new Date().toLocaleTimeString()}</p>
{/* BUG: Random content differs between renders */}
<p>Random number: {Math.random()}</p>
{/* BUG: Browser-only API returns different values */}
<p>Screen width: {window.innerWidth}px</p>
</div>
);
}
Root Cause
Next.js renders your component twice: 1. On the server: Generates HTML 2. On the client: "Hydrates" the HTML (attaches event listeners, state)
If the server HTML doesn't match the client HTML, React can't hydrate correctly. This happens when:
- Using Date.now(), Math.random(), or other non-deterministic values
- Using browser APIs that don't exist on server
- Conditionally rendering based on browser state
The Trap
You create hydration mismatches because: 1. You don't understand server vs. client rendering 2. You use time-based or random values 3. You check browser state during render 4. You use third-party libraries that assume browser environment
The Fix: Ensure Consistent Rendering
Option 1: Move dynamic content to Client Component with useEffect:
// components/CurrentTime.tsx - Client Component
'use client';
import { useState, useEffect } from 'react';
export function CurrentTime() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
// Only set time on client, after hydration
setTime(new Date().toLocaleTimeString());
const interval = setInterval(() => {
setTime(new Date().toLocaleTimeString());
}, 1000);
return () => clearInterval(interval);
}, []);
// Server renders null, client renders time after hydration
if (!time) return <p>Loading time...</p>;
return <p>Current time: {time}</p>;
}
// app/tasks/page.tsx - Server Component
import { CurrentTime } from '@/components/CurrentTime';
export default function TasksPage() {
return (
<div>
<h1>Tasks</h1>
<CurrentTime />
</div>
);
}
Option 2: Use suppressHydrationWarning for known mismatches:
// Only use this when you KNOW the mismatch is intentional
export default function TasksPage() {
return (
<div>
<h1>Tasks</h1>
<p suppressHydrationWarning>
Current time: {new Date().toLocaleTimeString()}
</p>
</div>
);
}
Option 3: Use consistent data from server:
// app/tasks/page.tsx - Pass server time to client
export default function TasksPage() {
const serverTime = new Date().toISOString();
return (
<div>
<h1>Tasks</h1>
<TimeDisplay initialTime={serverTime} />
</div>
);
}
// components/TimeDisplay.tsx
'use client';
export function TimeDisplay({ initialTime }: { initialTime: string }) {
const [time, setTime] = useState(initialTime);
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date().toISOString());
}, 1000);
return () => clearInterval(interval);
}, []);
return <p>Time: {new Date(time).toLocaleTimeString()}</p>;
}
Common Hydration Mismatch Causes
1. Browser-only APIs:
// WRONG
<p>Width: {window.innerWidth}px</p>
// CORRECT
'use client';
const [width, setWidth] = useState<number | null>(null);
useEffect(() => {
setWidth(window.innerWidth);
}, []);
2. Date/Time:
// WRONG
<p>{new Date().toLocaleString()}</p>
// CORRECT
Pass server time as prop, update in useEffect
3. Random values:
// WRONG
<div key={Math.random()}>...</div>
// CORRECT
Use stable IDs from data
4. Conditional rendering based on browser state:
// WRONG
{typeof window !== 'undefined' && <Component />}
// CORRECT
Use useEffect to conditionally render after hydration
Prevention
- Avoid non-deterministic values during render
- Use useEffect for browser-only logic
- Pass server data to client components as props
- Test with JavaScript disabled to see server HTML
- Check Lighthouse for CLS issues caused by hydration mismatches
Pitfall 16: Mixing Server and Client Component Patterns
Symptom
"You're importing a component that needs X. It only works in a Client Component but none of its parents are marked with 'use client'." Build errors about importing client-only code in server components.
Browser Evidence
Terminal Output:
Error: You're importing a component that needs useState.
It only works in a Client Component but none of its parents
are marked with "use client", so they're Server Components by default.
Import trace:
./app/tasks/page.tsx
./components/TaskList.tsx
./components/TaskItem.tsx
// app/tasks/page.tsx - Server Component
import { TaskList } from '@/components/TaskList';
export default async function TasksPage() {
const tasks = await fetchTasks();
return (
<div>
<h1>Tasks</h1>
<TaskList tasks={tasks} />
</div>
);
}
// components/TaskList.tsx - Server Component (no 'use client')
import { TaskItem } from './TaskItem';
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<div>
{tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
// components/TaskItem.tsx - BUG: Uses hooks but no 'use client'
import { useState } from 'react';
export function TaskItem({ task }: { task: Task }) {
// BUG: useState in Server Component
const [isExpanded, setIsExpanded] = useState(false);
return (
<div>
<h3>{task.title}</h3>
<button onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? 'Collapse' : 'Expand'}
</button>
{isExpanded && <p>{task.description}</p>}
</div>
);
}
Root Cause
In Next.js App Router: - Components are Server Components by default - Server Components cannot use hooks (useState, useEffect, etc.) - Server Components cannot use browser APIs - When a Server Component imports a component that uses hooks, build fails
The 'use client' directive marks a component and all its children as Client Components.
The Trap
You mix patterns because:
1. You forget to add 'use client'
2. You don't understand the Server/Client boundary
3. You import a Client Component from a Server Component (this is fine)
4. You import a Server Component from a Client Component (this is problematic)
The Fix: Add 'use client' to Interactive Components
Mark components that need interactivity as Client Components:
// app/tasks/page.tsx - Server Component (can fetch data)
import { TaskList } from '@/components/TaskList';
export default async function TasksPage() {
const tasks = await fetchTasks();
return (
<div>
<h1>Tasks</h1>
<TaskList tasks={tasks} />
</div>
);
}
// components/TaskList.tsx - Server Component (no interactivity)
import { TaskItem } from './TaskItem';
export function TaskList({ tasks }: { tasks: Task[] }) {
return (
<div>
{tasks.map(task => (
<TaskItem key={task.id} task={task} />
))}
</div>
);
}
// components/TaskItem.tsx - CORRECT: Client Component
'use client';
import { useState } from 'react';
export function TaskItem({ task }: { task: Task }) {
const [isExpanded, setIsExpanded] = useState(false);
return (
<div>
<h3>{task.title}</h3>
<button onClick={() => setIsExpanded(!isExpanded)}>
{isExpanded ? 'Collapse' : 'Expand'}
</button>
{isExpanded && <p>{task.description}</p>}
</div>
);
}
Component Composition Patterns
Pattern 1: Server Component → Client Component (✅ Allowed):
// Server Component
export default function Page() {
return <ClientComponent />;
}
// Client Component
'use client';
export function ClientComponent() {
const [state, setState] = useState();
return <div>...</div>;
}
Pattern 2: Client Component → Server Component (❌ Not directly):
// WRONG: Can't import Server Component into Client Component
'use client';
export function ClientComponent() {
return <ServerComponent />; // Error!
}
Pattern 3: Client Component with Server Component as children (âś… Allowed):
// Server Component
export default function Page() {
return (
<ClientWrapper>
<ServerComponent />
</ClientWrapper>
);
}
// Client Component
'use client';
export function ClientWrapper({ children }: { children: React.ReactNode }) {
const [state, setState] = useState();
return <div>{children}</div>;
}
Decision Framework: Where to Place 'use client'
Place 'use client' as low as possible in the tree:
- âś… Only mark interactive components
- âś… Keep data fetching in Server Components
- âś… Pass data down as props
Example:
// âś… GOOD: Client boundary at leaf
export default async function Page() {
const data = await fetchData(); // Server
return (
<div>
<StaticHeader /> {/* Server */}
<InteractiveWidget data={data} /> {/* Client */}
</div>
);
}
// ❌ BAD: Client boundary at root
'use client';
export default function Page() {
const [data, setData] = useState();
useEffect(() => {
fetchData().then(setData); // Client-side fetch
}, []);
return (
<div>
<StaticHeader /> {/* Unnecessarily client-side */}
<InteractiveWidget data={data} />
</div>
);
}
Prevention
- Default to Server Components
- Add
'use client'only when needed (hooks, browser APIs, interactivity) - Push
'use client'down the tree as far as possible - Pass data as props from Server to Client Components
- Use composition to nest Server Components inside Client Components
Performance Pitfalls
Pitfall 17: Premature Optimization
Symptom
Code is complex and hard to maintain. Performance is not noticeably better. Time spent optimizing instead of building features. Over-use of useMemo, useCallback, React.memo.
Browser Evidence
React DevTools - Profiler: - Render time: 2.3ms (before optimization) - Render time: 2.1ms (after optimization) - Improvement: 0.2ms (negligible)
Code Complexity: - Before: 50 lines, easy to understand - After: 120 lines, wrapped in useMemo/useCallback everywhere
// TaskManager.tsx - WRONG: Over-optimized
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState('all');
const [sort, setSort] = useState('date');
// Unnecessary useMemo for simple filter
const filteredTasks = useMemo(() => {
return tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
}, [tasks, filter]);
// Unnecessary useMemo for simple sort
const sortedTasks = useMemo(() => {
return [...filteredTasks].sort((a, b) => {
if (sort === 'date') {
return b.createdAt.getTime() - a.createdAt.getTime();
}
return a.title.localeCompare(b.title);
});
}, [filteredTasks, sort]);
// Unnecessary useCallback for simple function
const handleToggle = useCallback((id: string) => {
setTasks(tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
));
}, [tasks]);
// Unnecessary useCallback for simple function
const handleDelete = useCallback((id: string) => {
setTasks(tasks.filter(task => task.id !== id));
}, [tasks]);
return (
<div>
<TaskFilters
filter={filter}
onFilterChange={setFilter}
sort={sort}
onSortChange={setSort}
/>
<TaskList
tasks={sortedTasks}
onToggle={handleToggle}
onDelete={handleDelete}
/>
</div>
);
}
// Unnecessary React.memo for simple component
const TaskList = React.memo(function TaskList({
tasks,
onToggle,
onDelete
}: {
tasks: Task[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div>
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</div>
);
});
Root Cause
Premature optimization makes code more complex without measurable benefit. The overhead of useMemo/useCallback (checking dependencies, comparing values) can be more expensive than the computation you're trying to optimize.
React is already fast. Most components render in <5ms. Optimizing a 2ms render to 1ms is not worth the complexity.
The Trap
You over-optimize because: 1. You've heard "always use useMemo/useCallback" 2. You think optimization is always good 3. You don't measure before optimizing 4. You follow cargo-cult patterns without understanding
The Fix: Optimize Only When Needed
Start simple, optimize only when profiling shows a problem:
// TaskManager.tsx - CORRECT: Simple, readable, fast enough
function TaskManager() {
const [tasks, setTasks] = useState<Task[]>([]);
const [filter, setFilter] = useState('all');
const [sort, setSort] = useState('date');
// Compute during render - fast enough for most cases
const filteredTasks = tasks.filter(task => {
if (filter === 'active') return !task.completed;
if (filter === 'completed') return task.completed;
return true;
});
const sortedTasks = [...filteredTasks].sort((a, b) => {
if (sort === 'date') {
return b.createdAt.getTime() - a.createdAt.getTime();
}
return a.title.localeCompare(b.title);
});
const handleToggle = (id: string) => {
setTasks(tasks.map(task =>
task.id === id ? { ...task, completed: !task.completed } : task
));
};
const handleDelete = (id: string) => {
setTasks(tasks.filter(task => task.id !== id));
};
return (
<div>
<TaskFilters
filter={filter}
onFilterChange={setFilter}
sort={sort}
onSortChange={setSort}
/>
<TaskList
tasks={sortedTasks}
onToggle={handleToggle}
onDelete={handleDelete}
/>
</div>
);
}
// No React.memo needed unless profiling shows re-render issues
function TaskList({
tasks,
onToggle,
onDelete
}: {
tasks: Task[];
onToggle: (id: string) => void;
onDelete: (id: string) => void;
}) {
return (
<div>
{tasks.map(task => (
<TaskItem
key={task.id}
task={task}
onToggle={onToggle}
onDelete={onDelete}
/>
))}
</div>
);
}
When to Actually Optimize
Optimize when profiling shows these issues:
1. Expensive computation (>50ms):
// Profile shows this takes 80ms
const expensiveResult = computeExpensiveValue(data);
// Optimize with useMemo
const expensiveResult = useMemo(
() => computeExpensiveValue(data),
[data]
);
2. Large lists (>100 items):
// 1000 items, each re-renders unnecessarily
const TaskItem = React.memo(function TaskItem({ task }) {
return <div>{task.title}</div>;
});
3. Frequent re-renders (>10/second):
// Parent re-renders every 100ms, child doesn't need to
const ExpensiveChild = React.memo(function ExpensiveChild({ data }) {
return <ComplexVisualization data={data} />;
});
Optimization Decision Framework
Before optimizing, ask: 1. Is there a performance problem? (Measure with Profiler) 2. Is it noticeable to users? (>100ms is noticeable) 3. Is this the bottleneck? (Profile to find the real issue) 4. Will optimization help? (Sometimes the problem is elsewhere)
If yes to all, then optimize. Otherwise, keep it simple.
Prevention
- Profile first with React DevTools Profiler
- Measure impact before and after optimization
- Start simple, optimize only when needed
- Focus on user-perceived performance (loading states, responsiveness)
- Remember: Readable code > Premature optimization
Quick Reference: Diagnostic Decision Tree
Diagnostic Decision Tree
When your React app breaks, follow this decision tree to identify the issue:
1. Component Not Rendering
Symptom: Blank screen, component doesn't appear
Check: - Browser console for errors - React DevTools: Is component in tree? - Network tab: Did data fetch fail?
Common causes: - JavaScript error (check console) - Conditional rendering hiding component - CSS hiding component (check computed styles) - Data not loaded yet (add loading state)
2. Component Not Updating
Symptom: UI doesn't reflect state changes
Check: - React DevTools: Does state change? - Console log: Is setState being called? - React DevTools Profiler: Is component re-rendering?
Common causes: - Pitfall 2: Mutating state directly - Pitfall 1: Stale closure in event handler - Pitfall 10: Wrong key causing React to reuse component - Pitfall 9: Component defined inside component
3. Infinite Renders
Symptom: Browser freezes, console floods with logs
Check: - React DevTools Profiler: Render count - Console: What's logging repeatedly? - React DevTools: Which component is rendering?
Common causes: - Pitfall 8: useEffect with no dependency array - Pitfall 5: useEffect with missing dependencies causing loop - Pitfall 11: New object/array created on every render - setState inside render (without condition)
4. Memory Leak Warning
Symptom: "Can't perform a React state update on an unmounted component"
Check: - Console: Which component? - Network tab: Are requests completing after unmount? - Code: Are there timers or subscriptions?
Common causes: - Pitfall 7: Missing cleanup in useEffect - Fetch request completing after unmount - Timer/interval not cleared - Event listener not removed
5. Type Error at Runtime
Symptom: "Cannot read property of undefined", "X is not a function"
Check: - Console: Exact error message and stack trace - React DevTools: Component props and state - Network tab: Is API returning expected data?
Common causes:
- Pitfall 12: Type assertion hiding bug
- Pitfall 13: Using any instead of proper types
- API returning different data than expected
- Missing null/undefined check
6. Hydration Mismatch
Symptom: "Hydration failed" warning, content flashes
Check: - Console: Hydration warning details - View source: What's the server HTML? - React DevTools: What's the client HTML?
Common causes: - Pitfall 15: Using Date.now(), Math.random(), or browser APIs - Conditional rendering based on browser state - Third-party library assuming browser environment
7. Build Fails
Symptom: npm run build fails with error
Check: - Terminal: Full error message - Error location: File and line number - TypeScript errors vs. runtime errors
Common causes: - Pitfall 14: Using browser APIs in Server Component - Pitfall 16: Missing 'use client' directive - TypeScript errors - Missing environment variables
8. Slow Performance
Symptom: App feels sluggish, interactions lag
Check: - React DevTools Profiler: Which components render slowly? - Chrome Performance tab: What's blocking the main thread? - Network tab: Are there slow requests?
Common causes: - Pitfall 17: Premature optimization making code complex - Missing React.memo on expensive components - Large lists without virtualization - Expensive computation in render - Too many re-renders
Quick Diagnostic Checklist
When debugging, systematically check:
- Browser Console: Any errors or warnings?
- React DevTools - Components:
- Is component in tree?
- What are props/state values?
- Is component highlighted (re-rendering)?
- React DevTools - Profiler:
- How long does render take?
- How many times does it render?
- What triggered the render?
- Network Tab:
- Did requests complete?
- What's the response data?
- Are there failed requests?
- Console Logs:
- Add strategic logs to trace execution
- Log state before and after updates
- Log effect runs and cleanups
Common Error Messages Decoded
"Cannot read properties of undefined (reading 'X')"
Meaning: You're accessing a property on undefined
Fix: Add null check or optional chaining
// WRONG
<h1>{user.name}</h1>
// CORRECT
<h1>{user?.name ?? 'Guest'}</h1>
"Maximum update depth exceeded"
Meaning: Infinite render loop
Fix: Check useEffect dependencies and setState calls
// WRONG
useEffect(() => {
setState(value); // Runs on every render
});
// CORRECT
useEffect(() => {
setState(value);
}, [dependency]); // Only runs when dependency changes
"Objects are not valid as a React child"
Meaning: Trying to render an object directly
Fix: Render object properties, not the object itself
// WRONG
<div>{user}</div>
// CORRECT
<div>{user.name}</div>
"Each child in a list should have a unique 'key' prop"
Meaning: Missing or duplicate keys in list
Fix: Use stable, unique IDs as keys
// WRONG
{tasks.map((task, index) => <div key={index}>...</div>)}
// CORRECT
{tasks.map(task => <div key={task.id}>...</div>)}
"Can't perform a React state update on an unmounted component"
Meaning: setState called after component unmounted
Fix: Add cleanup to useEffect
useEffect(() => {
let isMounted = true;
fetchData().then(data => {
if (isMounted) setState(data);
});
return () => { isMounted = false; };
}, []);
"Rendered fewer hooks than expected"
Meaning: Conditional hook call
Fix: Never call hooks conditionally
// WRONG
if (condition) {
useState(0);
}
// CORRECT
const [state, setState] = useState(0);
if (condition) {
// Use state here
}
Conclusion: Building Your Debugging Intuition
Building Your Debugging Intuition
The pitfalls in this appendix represent the most common ways React applications fail. But memorizing them isn't enough—you need to develop debugging intuition.
The Expert's Debugging Process
When an expert React developer encounters a bug, they follow this mental model:
1. Observe the symptom - What does the user see? - What's the expected behavior? - What's the actual behavior?
2. Gather evidence - Browser console: Errors, warnings, logs - React DevTools: Component tree, props, state, render count - Network tab: Request/response data, timing - Performance tab: What's blocking the main thread?
3. Form a hypothesis - Based on the evidence, what's the most likely cause? - Which pitfall does this match? - What would confirm or refute this hypothesis?
4. Test the hypothesis - Add strategic console.logs - Use React DevTools to inspect state - Simplify the code to isolate the issue - Try the fix and verify it works
5. Understand the root cause - Why did this happen? - What mechanism caused this failure? - How can I prevent this in the future?
From Pitfall to Pattern
Each pitfall teaches a deeper lesson:
- Pitfall 1 (Stale Closure) → Understand JavaScript closures and React's render cycle
- Pitfall 2 (Direct Mutation) → Understand immutability and reference equality
- Pitfall 5 (Missing Dependencies) → Understand effect dependencies and closures
- Pitfall 7 (Missing Cleanup) → Understand component lifecycle and memory management
- Pitfall 10 (Index as Key) → Understand React's reconciliation algorithm
- Pitfall 14 (Client APIs in Server) → Understand Next.js rendering model
The Path to Mastery
Mastery comes from: 1. Encountering failures and understanding why they happened 2. Reading error messages systematically to extract all information 3. Using DevTools to observe what's actually happening 4. Building mental models of how React works under the hood 5. Recognizing patterns across different failures
Your Debugging Toolkit
Keep these tools sharp:
Browser DevTools: - Console: Read every error message carefully - Network: Understand request/response patterns - Performance: Profile slow interactions - Elements: Inspect computed styles and DOM
React DevTools: - Components: Inspect props, state, hooks - Profiler: Measure render performance - Highlight updates: See what's re-rendering
Code Techniques: - Strategic console.logs - Simplification: Remove code until it works - Isolation: Test components in isolation - Type checking: Let TypeScript catch errors early
Final Wisdom
The best debugging skill is prevention. Write code that: - Uses TypeScript to catch errors at compile time - Validates external data at runtime - Follows React's rules (immutability, stable keys, proper dependencies) - Is simple and readable (complexity breeds bugs)
When bugs do occur (and they will), approach them systematically: 1. Don't guess—gather evidence 2. Don't fix symptoms—understand root causes 3. Don't move on—learn the lesson
Every bug you encounter and fix makes you a better React developer. This appendix is your field guide, but experience is your teacher.
Happy debugging!